2026-02-14 10:30:27 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Album authentication helpers.
|
|
|
|
|
|
* Uses HMAC tokens stored in HttpOnly cookies — no database needed.
|
|
|
|
|
|
*
|
2026-03-07 09:27:47 +01:00
|
|
|
|
* Token = HMAC-SHA256(slug + passwordSecret, authSecret)
|
2026-02-14 10:30:27 +01:00
|
|
|
|
* Cookie = lb_auth_<slug> = <token>
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import crypto from 'crypto';
|
|
|
|
|
|
import { getConfig, SubpageConfig } from './config';
|
|
|
|
|
|
|
|
|
|
|
|
const TOKEN_EXPIRY_HOURS = 24;
|
|
|
|
|
|
|
2026-03-07 09:27:47 +01:00
|
|
|
|
/**
|
2026-03-08 21:12:52 +01:00
|
|
|
|
* Check if a string looks like a legacy bcrypt hash.
|
2026-03-07 09:27:47 +01:00
|
|
|
|
*/
|
2026-03-08 21:12:52 +01:00
|
|
|
|
function isBcryptHash(str: string): boolean {
|
2026-03-07 09:27:47 +01:00
|
|
|
|
return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 21:12:52 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* Native SCrypt verify (format: 'scrypt:salt:hash_hex')
|
|
|
|
|
|
*/
|
|
|
|
|
|
function verifyScrypt(password: string, stored: string): boolean {
|
|
|
|
|
|
if (!stored.startsWith('scrypt:')) return false;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [, salt, hashHex] = stored.split(':');
|
|
|
|
|
|
const key = crypto.scryptSync(password, salt, 64);
|
|
|
|
|
|
const expectedKey = Buffer.from(hashHex, 'hex');
|
|
|
|
|
|
if (key.length !== expectedKey.length) return false;
|
|
|
|
|
|
return crypto.timingSafeEqual(key, expectedKey);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Helper to generate an scrypt string to print in logs.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function generateScryptHash(password: string): string {
|
|
|
|
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
|
|
|
|
|
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
|
|
|
|
|
return `scrypt:${salt}:${hash}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 10:30:27 +01:00
|
|
|
|
function hmac(data: string): string {
|
2026-03-07 08:18:23 +01:00
|
|
|
|
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
function authToken(key: string, passwordSecret: string): string {
|
2026-03-07 09:27:47 +01:00
|
|
|
|
// Use the hash/password as part of the HMAC for a secure, unique session token
|
2026-04-07 23:04:03 +02:00
|
|
|
|
return hmac(`${key}:${passwordSecret}`);
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
function cookieName(key: string, type: 'subpage' | 'album'): string {
|
|
|
|
|
|
return type === 'subpage' ? `lb_auth_${key}` : `lb_auth_album_${key}`;
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-07 23:04:03 +02:00
|
|
|
|
* Find the password secret for a given subpage slug or album ID.
|
2026-02-14 10:30:27 +01:00
|
|
|
|
*/
|
2026-04-07 23:04:03 +02:00
|
|
|
|
function findPassword(key: string, type: 'subpage' | 'album'): string | undefined {
|
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
|
if (type === 'subpage') {
|
|
|
|
|
|
return config.subpages.find((sp) => sp.slug === key)?.password;
|
|
|
|
|
|
}
|
|
|
|
|
|
return config.albumPasswords[key];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check if a subpage slug or album ID is password-protected.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function isProtected(key: string, type: 'subpage' | 'album' = 'subpage'): boolean {
|
|
|
|
|
|
return !!findPassword(key, type);
|
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-04-07 23:04:03 +02:00
|
|
|
|
export function authenticate(
|
|
|
|
|
|
key: string,
|
|
|
|
|
|
password: string,
|
|
|
|
|
|
type: 'subpage' | 'album' = 'subpage',
|
|
|
|
|
|
): string | null {
|
|
|
|
|
|
const storedPassword = findPassword(key, type);
|
|
|
|
|
|
if (!storedPassword) return null;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-03-07 09:27:47 +01:00
|
|
|
|
let isValid = false;
|
2026-03-08 21:12:52 +01:00
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
if (isBcryptHash(storedPassword)) {
|
2026-03-08 21:12:52 +01:00
|
|
|
|
console.error(
|
2026-04-07 23:04:03 +02:00
|
|
|
|
`\n❌ SECURITY ERROR: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using an outdated bcrypt password hash.\n` +
|
2026-03-12 11:30:45 +00:00
|
|
|
|
` 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`,
|
2026-03-08 21:12:52 +01:00
|
|
|
|
);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
if (storedPassword.startsWith('scrypt:')) {
|
|
|
|
|
|
isValid = verifyScrypt(password, storedPassword);
|
2026-03-07 09:27:47 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
// Plaintext fallback (deprecated)
|
2026-04-01 12:46:04 +02:00
|
|
|
|
// Hash both to a fixed length before constant-time comparison to prevent timing and length attacks
|
|
|
|
|
|
const attemptHash = crypto.createHash('sha256').update(password).digest();
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const storedHash = crypto.createHash('sha256').update(storedPassword).digest();
|
2026-04-01 12:46:04 +02:00
|
|
|
|
isValid = crypto.timingSafeEqual(attemptHash, storedHash);
|
|
|
|
|
|
|
2026-03-07 09:27:47 +01:00
|
|
|
|
if (isValid) {
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const recommendedHash = generateScryptHash(storedHash.toString('hex'));
|
2026-03-07 09:27:47 +01:00
|
|
|
|
console.warn(
|
2026-04-07 23:04:03 +02:00
|
|
|
|
`\n⚠️ SECURITY WARNING: ${type === 'subpage' ? 'Subpage' : 'Album'} "${key}" is using a plaintext password in gallery.yaml.\n` +
|
2026-03-12 11:30:45 +00:00
|
|
|
|
` Please replace it with this native secure hash:\n\n ${recommendedHash}\n`,
|
2026-03-07 09:27:47 +01:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValid) return null;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const token = authToken(key, storedPassword);
|
2026-02-14 11:47:26 +01:00
|
|
|
|
const maxAge = TOKEN_EXPIRY_HOURS * 60 * 60;
|
2026-02-15 11:23:51 +01:00
|
|
|
|
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
return `${cookieName(key, type)}=${token}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict${secure}`;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-07 23:04:03 +02:00
|
|
|
|
* Check if the request has a valid auth cookie for the given subpage or album.
|
2026-02-14 10:30:27 +01:00
|
|
|
|
* Works with the cookies() API from Next.js server components.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function isAuthenticated(
|
2026-04-07 23:04:03 +02:00
|
|
|
|
key: string,
|
2026-02-14 11:47:26 +01:00
|
|
|
|
getCookie: (name: string) => string | undefined,
|
2026-04-07 23:04:03 +02:00
|
|
|
|
type: 'subpage' | 'album' = 'subpage',
|
2026-02-14 10:30:27 +01:00
|
|
|
|
): boolean {
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const storedPassword = findPassword(key, type);
|
|
|
|
|
|
if (!storedPassword) return true; // not protected
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const cookie = getCookie(cookieName(key, type));
|
2026-02-14 11:47:26 +01:00
|
|
|
|
if (!cookie) return false;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-04-07 23:04:03 +02:00
|
|
|
|
const expected = authToken(key, storedPassword);
|
2026-02-14 11:47:26 +01:00
|
|
|
|
// 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
|
|
|
|
}
|