Derrière un proxy d'authentification, FanKarr demandait une seconde connexion. Avec PROXY_AUTH_HEADER, la session est ouverte à partir du nom d'utilisateur transmis par le proxy. - server/lib/proxy-auth.ts : lecture de l'en-tête, uniquement pour les connexions venant de PROXY_AUTH_TRUSTED_PROXIES (adresse du socket, pas X-Forwarded-For) ; fonction désactivée si cette liste est vide - compte rapproché par nom (sans casse) ; création d'un compte utilisateur sans mot de passe avec PROXY_AUTH_AUTO_CREATE ; premier compte toujours créé par l'assistant - server/auth.ts : middleware proxyAuth avant les routes, qui ouvre ou remplace la session ; /api/auth/status expose proxyAuth et logoutUrl - déconnexion : redirection vers PROXY_AUTH_LOGOUT_URL si défini - profil et gestion des utilisateurs : libellés pour les comptes sans mot de passe qui ne viennent pas de Jellyfin - tests vitest du module ; fichiers de test exclus de la compilation serveur Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
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<string>()
|
|
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
|
|
}
|
|
}
|