2026-02-14 10:30:27 +01:00
|
|
|
/**
|
|
|
|
|
* Album authentication helpers.
|
|
|
|
|
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
|
|
|
|
*
|
|
|
|
|
* Token = HMAC-SHA256(slug + password, apiKey)
|
|
|
|
|
* Cookie = lb_auth_<slug> = <token>
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
import { getConfig, SubpageConfig } from './config';
|
|
|
|
|
|
|
|
|
|
const TOKEN_EXPIRY_HOURS = 24;
|
|
|
|
|
|
|
|
|
|
function hmac(data: string): string {
|
2026-02-14 11:47:26 +01:00
|
|
|
return crypto.createHmac('sha256', getConfig().immich.apiKey).update(data).digest('hex');
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function authToken(slug: string, password: string): string {
|
2026-02-14 11:47:26 +01:00
|
|
|
return hmac(`${slug}:${password}`);
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cookieName(slug: string): string {
|
2026-02-14 11:47:26 +01:00
|
|
|
return `lb_auth_${slug}`;
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Find the SubpageConfig for a given slug.
|
|
|
|
|
*/
|
|
|
|
|
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
|
2026-02-14 11:47:26 +01:00
|
|
|
return getConfig().subpages.find((sp) => sp.slug === slug);
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if a slug corresponds to a password-protected subpage.
|
|
|
|
|
*/
|
|
|
|
|
export function isProtected(slug: string): boolean {
|
2026-02-14 11:47:26 +01:00
|
|
|
const sp = findSubpageBySlug(slug);
|
|
|
|
|
return !!sp?.password;
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate a password attempt and return a Set-Cookie header value on success.
|
|
|
|
|
* Returns null if the password is wrong.
|
|
|
|
|
*/
|
2026-02-14 11:47:26 +01:00
|
|
|
export function authenticate(slug: string, password: string): string | null {
|
|
|
|
|
const sp = findSubpageBySlug(slug);
|
|
|
|
|
if (!sp?.password) return null;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
if (password !== sp.password) return null;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
const token = authToken(slug, password);
|
|
|
|
|
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
return `${cookieName(slug)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict`;
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if the request has a valid auth cookie for the given slug.
|
|
|
|
|
* Works with the cookies() API from Next.js server components.
|
|
|
|
|
*/
|
|
|
|
|
export function isAuthenticated(
|
2026-02-14 11:47:26 +01:00
|
|
|
slug: string,
|
|
|
|
|
getCookie: (name: string) => string | undefined,
|
2026-02-14 10:30:27 +01:00
|
|
|
): boolean {
|
2026-02-14 11:47:26 +01:00
|
|
|
const sp = findSubpageBySlug(slug);
|
|
|
|
|
if (!sp?.password) return true; // not protected
|
2026-02-14 10:30:27 +01:00
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
const cookie = getCookie(cookieName(slug));
|
|
|
|
|
if (!cookie) return false;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
const expected = authToken(slug, sp.password);
|
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
|
|
|
try {
|
|
|
|
|
return crypto.timingSafeEqual(Buffer.from(cookie, 'hex'), Buffer.from(expected, 'hex'));
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-02-14 10:30:27 +01:00
|
|
|
}
|