Files
FanKarr/server/lib/jellyfin.ts
T
ElPouki bd8c233e45 feat(auth): connexion avec un compte Jellyfin (#154)
* fix(jellyfin): vérifier que le jeton Jellyfin appartient à l'utilisateur annoncé

L'échange du plugin (POST /api/v1/auth/jellyfin) validait le jeton avec
GET /Users/{jellyfinUserId}, que Jellyfin accepte avec le jeton de
n'importe quel utilisateur. Un utilisateur Jellyfin pouvait ainsi obtenir
le jeton d'API FanKarr d'un autre compte, administrateur compris.

Le jeton est maintenant vérifié avec GET /Users/Me, et son propriétaire
doit correspondre à jellyfinUserId.

* feat(auth): connexion avec un compte Jellyfin

Les utilisateurs Jellyfin se connectent à l'interface web avec leurs
identifiants Jellyfin, vérifiés par Jellyfin (POST /Users/AuthenticateByName).

- Import des utilisateurs Jellyfin au choix depuis Paramètres › Jellyfin et
  API ; l'import automatique horaire devient un réglage (activé par défaut).
  Un compte FanKarr du même nom est lié au lieu d'être recréé.
- Seuls les comptes importés peuvent se connecter avec Jellyfin, sauf si
  « Autoriser les comptes pas encore importés » est activé (désactivé par
  défaut) : le compte invité est alors créé à la première connexion, depuis
  l'interface web ou le plugin.
- Interrupteur « Connexion à FanKarr avec un compte Jellyfin » (activé par
  défaut).
- L'identifiant du serveur Jellyfin est enregistré et vérifié à chaque
  connexion ; la session Jellyfin ouverte pour la vérification est fermée
  aussitôt.
- Connexion limitée à 5 échecs en 15 minutes par nom d'utilisateur et adresse.
- Les comptes importés n'ont pas de mot de passe FanKarr : la page Profil
  l'indique, l'administrateur peut toujours en définir un.
2026-09-21 16:27:04 +02:00

186 lines
8.0 KiB
TypeScript

import crypto from 'crypto'
import { readSettings, writeSettings, type Settings } from '../settings.js'
import { logger } from '../logger.js'
const CLIENT_HEADER = 'MediaBrowser Client="FanKarr", Device="Server", DeviceId="fankarr-server", Version="1.0"'
const TIMEOUT_MS = 10_000
export function isJellyfinConfigured(s: Settings = readSettings()): boolean {
return !!(s.jellyfinUrl && s.jellyfinAdminToken)
}
function adminHeaders(token: string): Record<string, string> {
return {
'X-Emby-Token': token,
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
function userHeaders(token: string): Record<string, string> {
return {
'Authorization': `${CLIENT_HEADER}, Token="${token}"`,
'Content-Type': 'application/json',
}
}
// ── Types Jellyfin ────────────────────────────────────────────
export interface JellyfinUser {
Id : string
Name : string
ServerId? : string
HasPassword : boolean
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
}
// ── Test de connexion ─────────────────────────────────────────
export async function testJellyfinConnection(url: string, token: string): Promise<{ ok: boolean; version?: string; error?: string }> {
try {
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}
// ── Récupération des utilisateurs Jellyfin ────────────────────
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
return res.json()
}
// ── Identifiant du serveur ────────────────────────────────────
export async function getJellyfinServerId(): Promise<string | null> {
const { jellyfinUrl, jellyfinAdminToken, jellyfinServerId } = readSettings()
if (jellyfinServerId) return jellyfinServerId
if (!jellyfinUrl || !jellyfinAdminToken) return null
try {
const res = await fetch(`${jellyfinUrl.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(jellyfinAdminToken),
signal : AbortSignal.timeout(TIMEOUT_MS),
})
if (!res.ok) return null
const data = await res.json()
if (!data?.Id) return null
const id = normalizeJellyfinId(data.Id)
writeSettings({ jellyfinServerId: id })
logger.info('jellyfin', `Identifiant du serveur Jellyfin enregistré (${id.slice(0, 8)}…)`)
return id
} catch {
return null
}
}
// ── Authentification par nom et mot de passe ──────────────────
export type JellyfinAuthResult =
| { ok: true; id: string; name: string; serverId: string }
| { ok: false; reason: 'credentials' | 'unreachable'; error?: string }
export async function authenticateJellyfinUser(username: string, password: string, clientIp?: string): Promise<JellyfinAuthResult> {
const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { ok: false, reason: 'unreachable', error: 'Jellyfin non configuré' }
const base = jellyfinUrl.replace(/\/$/, '')
const deviceId = `fankarr-web-${crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16)}`
const client = `MediaBrowser Client="FanKarr", Device="FanKarr (connexion web)", DeviceId="${deviceId}", Version="1.0"`
let data: any
try {
const res = await fetch(`${base}/Users/AuthenticateByName`, {
method : 'POST',
headers: {
'Authorization': client,
'Content-Type' : 'application/json',
...(clientIp ? { 'X-Forwarded-For': clientIp.replace(/^::ffff:/, '') } : {}),
},
body : JSON.stringify({ Username: username, Pw: password }),
signal: AbortSignal.timeout(TIMEOUT_MS),
})
if (res.status === 401 || res.status === 403) return { ok: false, reason: 'credentials' }
if (!res.ok) return { ok: false, reason: 'unreachable', error: `Jellyfin a répondu HTTP ${res.status}` }
data = await res.json()
} catch (err) {
return { ok: false, reason: 'unreachable', error: err instanceof Error ? err.message : 'Erreur réseau' }
}
if (!data?.User?.Id) return { ok: false, reason: 'unreachable', error: 'Réponse Jellyfin inattendue' }
// Session fermée aussitôt : FanKarr ne garde aucun jeton Jellyfin
if (data.AccessToken) {
fetch(`${base}/Sessions/Logout`, {
method : 'POST',
headers: { 'Authorization': `${client}, Token="${data.AccessToken}"` },
signal : AbortSignal.timeout(TIMEOUT_MS),
}).catch(() => {})
}
return {
ok : true,
id : normalizeJellyfinId(data.User.Id),
name : data.User.Name,
serverId: normalizeJellyfinId(data.ServerId ?? data.User.ServerId ?? ''),
}
}
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo {
valid : boolean
userId? : string
username?: string
error? : string
}
export function normalizeJellyfinId(id: string): string {
return String(id).replace(/-/g, '').toLowerCase()
}
export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToken: string): Promise<JellyfinTokenInfo> {
const { jellyfinUrl } = readSettings()
if (!jellyfinUrl) return { valid: false, error: 'Jellyfin non configuré sur ce serveur FanKarr' }
try {
const base = jellyfinUrl.replace(/\/$/, '')
// /Users/{id}
const res = await fetch(`${base}/Users/Me`, {
headers: userHeaders(jellyfinToken),
})
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json()
if (normalizeJellyfinId(user.Id) !== normalizeJellyfinId(jellyfinUserId)) {
logger.warn('jellyfin', `Jeton de « ${user.Name} » présenté pour un autre utilisateur Jellyfin (${String(jellyfinUserId).slice(0, 8)}…)`)
return { valid: false, error: 'Ce jeton Jellyfin appartient à un autre utilisateur' }
}
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
const serverId = await getJellyfinServerId()
if (serverId && user.ServerId && normalizeJellyfinId(user.ServerId) !== serverId) {
logger.error('jellyfin', `Jeton de « ${user.Name} » refusé : il vient d'un autre serveur Jellyfin que celui configuré`)
return { valid: false, error: 'Ce jeton vient d\'un autre serveur Jellyfin' }
}
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
}
}