Files
immich-folio/lib/auth.ts
Ralksta 2f546f440d release: v0.11.0 (#459)
Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not,
so this is a clean sync.

## Headline changes since v0.10.0

- **Journal & Journal Studio** — photo essays at `/journal`,
split-screen block editor with live preview, drafts, per-entry
passwords, cover images
- **First-run setup wizard at `/install`** — configure a fresh
deployment from the browser; credentials are verified against Immich
before anything is written, and the wizard is gated by a one-time token
- **About page editor and toggle** in the admin panel
- **Per-album photo order** independent of Immich, with a drag & drop
editor
- **Custom favicon upload**
- **Experimental portfolio features** — justified grid, `cover` hero,
unlisted subpages, per-album grid overrides, cover focal points,
external nav links
- **Security** — asset tokens length-capped before decoding (#423),
journal author markdown escaped rather than filtered, journal file paths
contained, admin session key derived with scrypt (#432), Next.js 16.3.0
for CVE-2025-13465
- **Fixed** — client proofing honours its own configuration again
(#454); journal photo rendering (#432)

Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15).

## Verified

`npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run
build` succeeds · lint 0 errors · `prettier --check` clean · local
`docker build` of the release tree.

## After merge

Tag `v0.11.0` on `main` and publish the release — that triggers
`docker-publish.yml`, which pushes the image and moves `latest`.

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

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:09:18 +02:00

181 lines
6.0 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.
/**
* Album authentication helpers.
* 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';
import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24;
/**
* 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: 'subpage' | 'album' | 'journal'): string {
if (type === 'subpage') return `lb_auth_${key}`;
if (type === 'journal') return `lb_auth_journal_${key}`;
return `lb_auth_album_${key}`;
}
/**
* Find the SubpageConfig for a given slug.
*/
export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
return getConfig().subpages.find((sp) => sp.slug === slug);
}
/**
* Find the password secret for a given subpage slug, album ID, or journal entry.
*/
function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): string | undefined {
const config = getConfig();
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.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: 'subpage' | 'album' | 'journal' = '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: 'subpage' | 'album' | 'journal' = '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 === 'subpage' ? 'Subpage' : 'Album'} "${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 === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password 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(key, 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: 'subpage' | 'album' | 'journal' = '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(key, 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;
}
}