import net from 'net' import type { Request } from 'express' import { logger } from '../logger.js' import { findByUsername, createUser, hasUsers, type User } from '../users.js' // ── Authentification déléguée à un proxy (oauth2-proxy, Authelia…) ── // Le proxy authentifie l'utilisateur et transmet son nom dans un en-tête. // L'en-tête n'est lu que pour les connexions venant d'une adresse déclarée // dans PROXY_AUTH_TRUSTED_PROXIES : sans cela, n'importe quel client joignant // directement le port de FanKarr pourrait se faire passer pour un autre. function parseBool(raw: string | undefined): boolean { return ['1', 'true', 'yes', 'on'].includes((raw ?? '').trim().toLowerCase()) } type Matcher = (ip: string) => boolean function ipv4ToInt(ip: string): number { return ip.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0 } // '::ffff:172.18.0.5' (IPv4 vue par un socket IPv6) → '172.18.0.5' export function normalizeIp(ip: string): string { const trimmed = ip.trim() const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(trimmed) return mapped ? mapped[1] : trimmed.toLowerCase() } // Entrée acceptée : adresse IPv4/IPv6 exacte ou plage IPv4 en notation CIDR function parseTrustedEntry(entry: string): Matcher | null { const [addr, bitsRaw] = entry.split('/') const ip = normalizeIp(addr) if (bitsRaw === undefined) { return net.isIP(ip) ? (candidate) => candidate === ip : null } const bits = Number(bitsRaw) if (net.isIPv4(ip) && Number.isInteger(bits) && bits >= 0 && bits <= 32) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0 const base = ipv4ToInt(ip) & mask return (candidate) => net.isIPv4(candidate) && (ipv4ToInt(candidate) & mask) === base } return null } export function parseTrustedProxies(raw: string | undefined): Matcher[] { const matchers: Matcher[] = [] for (const entry of (raw ?? '').split(',').map(e => e.trim()).filter(Boolean)) { const matcher = parseTrustedEntry(entry) if (matcher) matchers.push(matcher) else logger.warn('auth', `PROXY_AUTH_TRUSTED_PROXIES : « ${entry} » ignoré (attendu : adresse IP ou plage IPv4 CIDR)`) } return matchers } export function isTrustedAddress(remoteAddress: string | undefined, matchers: Matcher[]): boolean { if (!remoteAddress) return false const ip = normalizeIp(remoteAddress) return matchers.some(match => match(ip)) } const HEADER = (process.env.PROXY_AUTH_HEADER ?? '').trim().toLowerCase() const TRUSTED = parseTrustedProxies(process.env.PROXY_AUTH_TRUSTED_PROXIES) const AUTO_CREATE = parseBool(process.env.PROXY_AUTH_AUTO_CREATE) const LOGOUT_URL = (process.env.PROXY_AUTH_LOGOUT_URL ?? '').trim() export const PROXY_AUTH_ENABLED = (() => { if (!HEADER) return false if (TRUSTED.length === 0) { logger.error('auth', 'PROXY_AUTH_HEADER est défini mais PROXY_AUTH_TRUSTED_PROXIES est vide : authentification par proxy désactivée') return false } logger.info('auth', `Authentification par proxy activée (en-tête ${HEADER}${AUTO_CREATE ? ', création automatique des comptes' : ''})`) return true })() export const PROXY_AUTH_LOGOUT_URL = PROXY_AUTH_ENABLED ? LOGOUT_URL : '' // Avertissements déjà émis, pour ne pas les répéter à chaque requête const warned = new Set() function warnOnce(key: string, message: string): void { if (warned.has(key) || warned.size > 1_000) return warned.add(key) logger.warn('auth', message) } // Compte FanKarr désigné par le proxy, ou undefined si la requête n'en vient pas export function proxyAuthUser(req: Request): User | undefined { if (!PROXY_AUTH_ENABLED) return undefined const raw = req.headers[HEADER] const username = (Array.isArray(raw) ? raw[0] : raw)?.trim() if (!username) return undefined // Adresse du socket et non req.ip : X-Forwarded-For est fourni par le client if (!isTrustedAddress(req.socket.remoteAddress, TRUSTED)) { warnOnce(`ip|${req.socket.remoteAddress}`, `En-tête ${HEADER} ignoré : la connexion vient de ${req.socket.remoteAddress}, absent de PROXY_AUTH_TRUSTED_PROXIES`) return undefined } // Premier lancement : le compte admin est créé par l'assistant if (!hasUsers()) return undefined const user = findByUsername(username) if (user) return user if (!AUTO_CREATE) { warnOnce(`user|${username.toLowerCase()}`, `Utilisateur « ${username} » transmis par le proxy inconnu de FanKarr : créez le compte ou activez PROXY_AUTH_AUTO_CREATE`) return undefined } try { // Sans mot de passe FanKarr, comme un compte importé de Jellyfin : la connexion passe par le proxy return createUser(username, '', 'user') } catch (err) { logger.error('auth', `Création du compte « ${username} » transmis par le proxy impossible : ${err instanceof Error ? err.message : err}`) return undefined } }