release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
124
lib/auth.ts
124
lib/auth.ts
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Album authentication helpers.
|
||||
* 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)
|
||||
@@ -12,6 +12,42 @@ 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.
|
||||
*/
|
||||
@@ -40,12 +76,22 @@ import nodeFs from 'fs';
|
||||
import { parseFrontmatter } from './journal';
|
||||
import { resolveJournalFilePath } from './admin/journal-service';
|
||||
|
||||
function cookieName(key: string, type: 'subpage' | 'album' | 'journal'): string {
|
||||
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';
|
||||
|
||||
/**
|
||||
* Find the SubpageConfig for a given slug.
|
||||
*/
|
||||
@@ -56,8 +102,11 @@ export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
|
||||
/**
|
||||
* Find the password secret for a given subpage slug, album ID, or journal entry.
|
||||
*/
|
||||
function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): string | undefined {
|
||||
function findPassword(key: string, type: ProtectedType): string | undefined {
|
||||
const config = getConfig();
|
||||
if (type === 'site') {
|
||||
return config.sitePassword || undefined;
|
||||
}
|
||||
if (type === 'subpage') {
|
||||
return config.subpages.find((sp) => sp.slug === key)?.password;
|
||||
}
|
||||
@@ -80,10 +129,7 @@ function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): strin
|
||||
/**
|
||||
* Check if a subpage slug, album ID, or journal entry is password-protected.
|
||||
*/
|
||||
export function isProtected(
|
||||
key: string,
|
||||
type: 'subpage' | 'album' | 'journal' = 'subpage',
|
||||
): boolean {
|
||||
export function isProtected(key: string, type: ProtectedType = 'subpage'): boolean {
|
||||
return !!findPassword(key, type);
|
||||
}
|
||||
|
||||
@@ -94,7 +140,7 @@ export function isProtected(
|
||||
export async function authenticate(
|
||||
key: string,
|
||||
password: string,
|
||||
type: 'subpage' | 'album' | 'journal' = 'subpage',
|
||||
type: ProtectedType = 'subpage',
|
||||
): Promise<string | null> {
|
||||
const storedPassword = findPassword(key, type);
|
||||
if (!storedPassword) return null;
|
||||
@@ -103,7 +149,7 @@ export async function authenticate(
|
||||
|
||||
if (isBcryptHash(storedPassword)) {
|
||||
console.error(
|
||||
`\n❌ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using an outdated bcrypt password hash.\n` +
|
||||
`\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`,
|
||||
@@ -129,7 +175,7 @@ export async function authenticate(
|
||||
if (isValid) {
|
||||
const recommendedHash = await generateScryptHash(storedPassword);
|
||||
console.warn(
|
||||
`\n⚠️ SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` +
|
||||
`\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`,
|
||||
);
|
||||
}
|
||||
@@ -138,7 +184,7 @@ export async function authenticate(
|
||||
if (!isValid) return null;
|
||||
|
||||
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
||||
const token = authToken(key, storedPassword, Date.now() + maxAge * 1000);
|
||||
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}`;
|
||||
@@ -151,7 +197,7 @@ export async function authenticate(
|
||||
export function isAuthenticated(
|
||||
key: string,
|
||||
getCookie: (name: string) => string | undefined,
|
||||
type: 'subpage' | 'album' | 'journal' = 'subpage',
|
||||
type: ProtectedType = 'subpage',
|
||||
): boolean {
|
||||
const storedPassword = findPassword(key, type);
|
||||
if (!storedPassword) return true; // not protected
|
||||
@@ -167,7 +213,7 @@ export function isAuthenticated(
|
||||
const expiresAt = Number(cookie.slice(0, sep));
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
|
||||
|
||||
const expected = authToken(key, storedPassword, expiresAt);
|
||||
const expected = authToken(tokenKey(key, type), storedPassword, expiresAt);
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
try {
|
||||
const a = Buffer.from(cookie, 'utf8');
|
||||
@@ -178,3 +224,55 @@ export function isAuthenticated(
|
||||
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' } },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user