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.
This commit is contained in:
ElPouki authored and GitHub committed 2026-09-21 16:27:04 +02:00
1 parent 8940ced4b1
commit bd8c233e45
17 files changed
+649 -142

No files matched your search

+85 -18
View File
@@ -7,12 +7,14 @@ import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import {
findByUsername, findById, findByApiToken,
findByUsername, findById, findByApiToken, findByJellyfinId, importJellyfinUser,
createUser, changePassword, regenerateApiToken, safeUser, hasUsers, markTourSeen, markLogin,
type User,
} from './users.js'
import { readSettings } from './settings.js'
import { readSettings, type Settings } from './settings.js'
import { startOnboarding } from './onboarding.js'
import { isJellyfinConfigured, authenticateJellyfinUser, getJellyfinServerId } from './lib/jellyfin.js'
import { loginRetryAfter, recordLoginFailure, clearLoginFailures } from './lib/login-throttle.js'
// ── Typage Express étendu ─────────────────────────────────────
declare global {
@@ -42,24 +44,30 @@ function setCookieAndRespond(res: Response, user: User): void {
// ── Routes ────────────────────────────────────────────────────
function jellyfinLoginEnabled(s: Settings): boolean {
return s.jellyfinLogin && isJellyfinConfigured(s)
}
// GET /api/auth/status
export function authStatus(req: Request, res: Response): void {
const setup = hasUsers()
const token = req.cookies?.fankarr_token
const settings = readSettings()
const setup = hasUsers()
const jellyfinLogin = jellyfinLoginEnabled(settings)
const token = req.cookies?.fankarr_token
if (!token) { res.json({ setup, loggedIn: false }); return }
if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin }); return }
try {
const payload = jwt.verify(token, JWT_SECRET) as any
const user = findById(payload.id)
if (!user) { res.json({ setup, loggedIn: false }); return }
if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin }); return }
res.json({
setup, loggedIn: true, role: user.role, username: user.username, userId: user.id,
onboardingDone: !!readSettings().onboardingCompletedAt,
setup, loggedIn: true, jellyfinLogin, role: user.role, username: user.username, userId: user.id,
onboardingDone: !!settings.onboardingCompletedAt,
tourSeen : !!user.tourSeenAt,
})
} catch {
res.json({ setup, loggedIn: false })
res.json({ setup, loggedIn: false, jellyfinLogin })
}
}
@@ -88,25 +96,84 @@ export function authSetup(req: Request, res: Response): void {
}
}
// Compte FanKarr lié à l'utilisateur Jellyfin authentifié, ou undefined si refusé
async function loginWithJellyfin(username: string, password: string, ip: string): Promise<{ user?: User; unreachable?: boolean }> {
const result = await authenticateJellyfinUser(username, password, ip)
if (!result.ok) {
if (result.reason === 'unreachable') {
logger.warn('auth', `Connexion Jellyfin de « ${username} » impossible : ${result.error}`)
return { unreachable: true }
}
return {}
}
const serverId = await getJellyfinServerId()
if (!serverId) {
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : impossible de lire l'identifiant du serveur Jellyfin avec la clé API`)
return {}
}
if (result.serverId !== serverId) {
logger.error('auth', `Connexion Jellyfin de « ${username} » refusée : la réponse vient d'un autre serveur Jellyfin que celui configuré`)
return {}
}
const linked = findByJellyfinId(result.id)
if (linked) return { user: linked }
if (!readSettings().jellyfinNewUserLogin) {
logger.warn('auth', `Connexion Jellyfin de « ${result.name} » refusée : compte pas encore importé`)
return {}
}
const imported = importJellyfinUser({ Id: result.id, Name: result.name })
if (imported?.action === 'created') logger.info('auth', `Compte créé pour « ${result.name} » à sa première connexion Jellyfin`)
return { user: imported?.user }
}
// POST /api/auth/login
export function authLogin(req: Request, res: Response): void {
export async function authLogin(req: Request, res: Response): Promise<void> {
if (!hasUsers()) {
res.status(400).json({ error: 'Aucun compte configuré' }); return
}
const { username, password } = req.body
if (!username || !password) {
const { username, password } = req.body ?? {}
if (typeof username !== 'string' || typeof password !== 'string' || !username || !password) {
res.status(400).json({ error: 'Nom d\'utilisateur et mot de passe requis' }); return
}
const user = findByUsername(username)
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' }); return
const ip = req.ip ?? ''
const wait = loginRetryAfter(ip, username)
if (wait > 0) {
logger.warn('auth', `Connexion de « ${username} » bloquée : trop de tentatives depuis ${ip}`)
res.set('Retry-After', String(wait))
res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${Math.ceil(wait / 60)} min.` }); return
}
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY})`)
setCookieAndRespond(res, user)
const session = NO_EXPIRY ? 'sans expiration' : TOKEN_EXPIRY
const user = findByUsername(username)
if (user && bcrypt.compareSync(password, user.passwordHash)) {
clearLoginFailures(ip, username)
logger.info('auth', `Connexion de « ${username} » (rôle : ${user.role}, session : ${session})`)
setCookieAndRespond(res, user); return
}
// Jellyfin n'est interrogé que pour un compte importé, ou un nouveau compte si le réglage l'autorise
const settings = readSettings()
const tryJellyfin = jellyfinLoginEnabled(settings) && (user ? !!user.jellyfinId : settings.jellyfinNewUserLogin)
if (tryJellyfin) {
const jf = await loginWithJellyfin(username, password, ip)
if (jf.user) {
clearLoginFailures(ip, username)
logger.info('auth', `Connexion de « ${jf.user.username} » via Jellyfin (rôle : ${jf.user.role}, session : ${session})`)
setCookieAndRespond(res, jf.user); return
}
if (jf.unreachable) {
res.status(503).json({ error: 'Connexion Jellyfin impossible : Jellyfin ne répond pas.' }); return
}
}
recordLoginFailure(ip, username)
logger.warn('auth', `Échec de la connexion de « ${username} »`)
res.status(401).json({ error: 'Identifiants incorrects' })
}
// POST /api/auth/logout
+8 -6
View File
@@ -25,7 +25,7 @@ import { pushNotif } from './lib/notifs.js'
import { AUTO_IMPORT_INTERVAL_MS, autoImportSchedule, planNextAutoImport } from './lib/schedule.js'
import { readRequests, completeRequest } from './requests.js'
import { checkNfoUpdates } from './lib/nfo.js'
import { runJellyfinSync } from './routes/jellyfin.js'
import { runJellyfinImport } from './routes/jellyfin.js'
import cors from 'cors';
import usersRouter from './routes/users.js'
@@ -266,11 +266,13 @@ server.listen(PORT, async () => {
}, 6 * 60 * 60_000)
}, 60_000)
// Sans effet si Jellyfin n'est pas configuré
// Sans effet si Jellyfin n'est pas configuré ou si l'import automatique est coupé
const autoImportJellyfin = () => {
if (!readSettings().jellyfinAutoImport) return
runJellyfinImport().catch(err => logger.error('jellyfin', `Échec de l'import des utilisateurs Jellyfin : ${err instanceof Error ? err.message : err}`))
}
setTimeout(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
runJellyfinSync().catch(err => logger.error('jellyfin', `Échec de la synchronisation Jellyfin : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
autoImportJellyfin()
setInterval(autoImportJellyfin, 60 * 60_000)
}, 2 * 60_000)
})
+100 -2
View File
@@ -1,8 +1,14 @@
import { readSettings } from '../settings.js'
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 {
@@ -24,6 +30,7 @@ function userHeaders(token: string): Record<string, string> {
export interface JellyfinUser {
Id : string
Name : string
ServerId? : string
HasPassword : boolean
Policy : { IsAdministrator: boolean; IsDisabled: boolean }
}
@@ -55,6 +62,82 @@ export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
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 {
@@ -64,21 +147,36 @@ export interface JellyfinTokenInfo {
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(/\/$/, '')
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
// /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) {
+37
View File
@@ -0,0 +1,37 @@
const MAX_FAILURES = 5
const WINDOW_MS = 15 * 60_000
const failures = new Map<string, { count: number; firstAt: number }>()
function keyOf(ip: string, username: string): string {
return `${ip}|${username.toLowerCase()}`
}
function sweep(now: number): void {
for (const [key, entry] of failures) {
if (now - entry.firstAt >= WINDOW_MS) failures.delete(key)
}
}
// Secondes avant une nouvelle tentative, 0 si la connexion est permise
export function loginRetryAfter(ip: string, username: string): number {
const key = keyOf(ip, username)
const entry = failures.get(key)
if (!entry) return 0
const elapsed = Date.now() - entry.firstAt
if (elapsed >= WINDOW_MS) { failures.delete(key); return 0 }
return entry.count >= MAX_FAILURES ? Math.ceil((WINDOW_MS - elapsed) / 1000) : 0
}
export function recordLoginFailure(ip: string, username: string): void {
const now = Date.now()
if (failures.size > 10_000) sweep(now)
const key = keyOf(ip, username)
const entry = failures.get(key)
if (!entry || now - entry.firstAt >= WINDOW_MS) failures.set(key, { count: 1, firstAt: now })
else entry.count++
}
export function clearLoginFailures(ip: string, username: string): void {
failures.delete(keyOf(ip, username))
}
+85 -35
View File
@@ -1,27 +1,39 @@
import { Router } from 'express'
import { readSettings, writeSettings } from '../settings.js'
import { readUsers, createUser, safeUser } from '../users.js'
import { testJellyfinConnection, fetchJellyfinUsers } from '../lib/jellyfin.js'
import { readSettings, writeSettings, type Settings } from '../settings.js'
import { readUsers, importJellyfinUser } from '../users.js'
import { testJellyfinConnection, fetchJellyfinUsers, getJellyfinServerId, normalizeJellyfinId, type JellyfinUser } from '../lib/jellyfin.js'
import { logger } from '../logger.js'
const router = Router()
function settingsView(s: Settings) {
return {
jellyfinUrl : s.jellyfinUrl,
hasToken : !!s.jellyfinAdminToken,
jellyfinLogin : s.jellyfinLogin,
jellyfinNewUserLogin: s.jellyfinNewUserLogin,
jellyfinAutoImport : s.jellyfinAutoImport,
}
}
router.get('/jellyfin/settings', (_req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
res.json({
jellyfinUrl,
// Clé API jamais renvoyée : seulement sa présence
hasToken: !!jellyfinAdminToken,
})
res.json(settingsView(readSettings()))
})
router.post('/jellyfin/settings', (req, res) => {
const { jellyfinUrl, jellyfinAdminToken } = req.body
const update: Record<string, string> = {}
const { jellyfinUrl, jellyfinAdminToken } = req.body ?? {}
const current = readSettings()
const update: Partial<Settings> = {}
if (jellyfinUrl !== undefined) update.jellyfinUrl = String(jellyfinUrl).trim()
if (jellyfinAdminToken !== undefined) update.jellyfinAdminToken = String(jellyfinAdminToken).trim()
const updated = writeSettings(update)
res.json({ jellyfinUrl: updated.jellyfinUrl, hasToken: !!updated.jellyfinAdminToken })
for (const key of ['jellyfinLogin', 'jellyfinNewUserLogin', 'jellyfinAutoImport'] as const) {
if (typeof req.body?.[key] === 'boolean') update[key] = req.body[key]
}
if ((update.jellyfinUrl !== undefined && update.jellyfinUrl !== current.jellyfinUrl) ||
(update.jellyfinAdminToken !== undefined && update.jellyfinAdminToken !== current.jellyfinAdminToken)) {
update.jellyfinServerId = ''
}
res.json(settingsView(writeSettings(update)))
})
router.post('/jellyfin/test', async (req, res) => {
@@ -45,40 +57,78 @@ router.post('/jellyfin/test', async (req, res) => {
res.json(result)
})
export async function runJellyfinSync(): Promise<{ created: number; skipped: number; users: string[] }> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, skipped: 0, users: [] }
// ── Import des utilisateurs ───────────────────────────────────
const jellyfinUsers = await fetchJellyfinUsers()
const fankarrUsers = readUsers()
const results = { created: 0, skipped: 0, users: [] as string[] }
export type JellyfinImportResult = { created: number; linked: number; skipped: number; users: string[] }
function importJellyfinUsers(jellyfinUsers: JellyfinUser[]): JellyfinImportResult {
const results: JellyfinImportResult = { created: 0, linked: 0, skipped: 0, users: [] }
for (const jUser of jellyfinUsers) {
if (jUser.Policy?.IsDisabled) { results.skipped++; continue }
const exists = fankarrUsers.some(u =>
u.username.toLowerCase() === jUser.Name.toLowerCase()
)
if (exists) { results.skipped++; continue }
const randomPass = Math.random().toString(36).slice(2, 10) +
Math.random().toString(36).slice(2, 10)
createUser(jUser.Name, randomPass, 'user')
results.created++
results.users.push(jUser.Name)
logger.info('jellyfin', `Compte créé pour « ${jUser.Name} » (synchronisation Jellyfin)`)
const imported = importJellyfinUser(jUser)
if (!imported || imported.action === 'existing') { results.skipped++; continue }
results[imported.action]++
results.users.push(imported.user.username)
}
logger.info('jellyfin', `Synchronisation Jellyfin : ${results.created} compte(s) créé(s), ${results.skipped} ignoré(s) (existant ou désactivé)`)
logger.info('jellyfin', `Import Jellyfin : ${results.created} compte(s) créé(s), ${results.linked} lié(s), ${results.skipped} ignoré(s) (déjà importé, désactivé ou nom déjà pris)`)
return results
}
export async function runJellyfinImport(): Promise<JellyfinImportResult> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) return { created: 0, linked: 0, skipped: 0, users: [] }
await getJellyfinServerId()
return importJellyfinUsers(await fetchJellyfinUsers())
}
router.get('/jellyfin/users', async (_req, res) => {
try {
const jellyfinUsers = await fetchJellyfinUsers()
const users = readUsers()
res.json(jellyfinUsers.map(j => {
const id = normalizeJellyfinId(j.Id)
const linked = users.find(u => u.jellyfinId === id)
const sameName = users.find(u => u.username.toLowerCase() === j.Name.toLowerCase())
const status = linked ? 'imported'
: j.Policy?.IsDisabled ? 'disabled'
: sameName?.jellyfinId ? 'conflict'
: sameName ? 'match'
: 'new'
return {
id,
name : j.Name,
status,
fankarrUsername: (linked ?? sameName)?.username ?? null,
}
}))
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : 'Impossible de récupérer les utilisateurs Jellyfin' })
}
})
router.post('/jellyfin/import', async (req, res) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((id: unknown) => normalizeJellyfinId(String(id))) : []
if (ids.length === 0) {
res.status(400).json({ error: 'Aucun utilisateur sélectionné' }); return
}
try {
await getJellyfinServerId()
const selected = (await fetchJellyfinUsers()).filter(j => ids.includes(normalizeJellyfinId(j.Id)))
res.json(importJellyfinUsers(selected))
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
}
})
// Import de tous les comptes actifs (assistant d'installation)
router.post('/jellyfin/sync', async (_req, res) => {
try {
const results = await runJellyfinSync()
res.json(results)
res.json(await runJellyfinImport())
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de la synchronisation Jellyfin' })
res.status(500).json({ error: err instanceof Error ? err.message : 'Échec de l\'import des utilisateurs Jellyfin' })
}
})
+7 -6
View File
@@ -1,8 +1,7 @@
import { Router } from 'express'
import { findByApiToken } from '../users.js'
import { findByApiToken, findByJellyfinId, importJellyfinUser, safeUser } from '../users.js'
import { validateJellyfinToken } from '../lib/jellyfin.js'
import { readUsers, safeUser } from '../users.js'
import { upsertRequest, approveRequest, getRequestsForUser } from '../requests.js'
import { autoDownloadRequest } from './requests.js'
import { readSettings } from '../settings.js'
@@ -41,12 +40,14 @@ router.post('/v1/auth/jellyfin', async (req, res) => {
res.status(401).json({ error: info.error ?? 'Échec de l\'authentification Jellyfin' }); return
}
const users = readUsers()
const fanUser = users.find(u => u.username.toLowerCase() === info.username!.toLowerCase())
let fanUser = findByJellyfinId(info.userId!)
if (!fanUser && readSettings().jellyfinNewUserLogin) {
fanUser = importJellyfinUser({ Id: info.userId!, Name: info.username! })?.user
}
if (!fanUser) {
logger.warn('public-api', `Connexion via Jellyfin : aucun compte FanKarr pour « ${info.username} » : synchronisation Jellyfin nécessaire`)
logger.warn('public-api', `Connexion via Jellyfin : « ${info.username} » n'est pas encore importé dans FanKarr`)
res.status(404).json({
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de lancer la synchronisation Jellyfin.`,
error: `Aucun compte FanKarr pour « ${info.username} ». Demandez à l'administrateur de l'importer depuis Paramètres › Jellyfin et API.`,
}); return
}
+11 -3
View File
@@ -15,8 +15,12 @@ export interface Settings {
deleteTorrentOnMove : boolean
requestAutoDownloadUsers: 'all' | string[] // Utilisateurs dont les demandes sont approuvées et téléchargées automatiquement ; [] = aucun
jellyfinUrl : string // URL du serveur Jellyfin (ex. http://jellyfin:8096)
jellyfinAdminToken : string // Clé API Jellyfin pour la synchronisation
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
jellyfinAdminToken : string // Clé API Jellyfin pour l'import des utilisateurs
jellyfinLogin : boolean // Connexion à l'interface web avec un compte Jellyfin importé
jellyfinNewUserLogin: boolean // Connexion des comptes Jellyfin pas encore importés (compte créé à la première connexion)
jellyfinAutoImport : boolean // Import horaire de tous les utilisateurs Jellyfin actifs
jellyfinServerId : string // Identifiant du serveur Jellyfin, comparé à chaque connexion
englishDirectory : boolean // Dossiers de saison en anglais (« Season 01 » au lieu de « Saison 1 »)
autoUnimportMissing : boolean
onboardingStep : string | null // étape la plus avancée atteinte dans l'assistant
onboardingCompletedAt: string | null
@@ -34,7 +38,11 @@ const defaults: Settings = {
requestAutoDownloadUsers: [],
jellyfinUrl : '',
jellyfinAdminToken : '',
englishDirectory : false,
jellyfinLogin : true,
jellyfinNewUserLogin: false,
jellyfinAutoImport : true,
jellyfinServerId : '',
englishDirectory : false,
autoUnimportMissing: false,
onboardingStep : null,
onboardingCompletedAt: null,
+38 -4
View File
@@ -5,6 +5,7 @@ import crypto from 'crypto'
import bcrypt from 'bcryptjs'
import { DATA_DIR } from './config.js'
import { logger } from './logger.js'
import { normalizeJellyfinId } from './lib/jellyfin.js'
export type UserRole = 'admin' | 'user'
@@ -17,6 +18,7 @@ export interface User {
createdAt : string
tourSeenAt? : string | null
lastLoginAt?: string | null
jellyfinId? : string | null
}
const USERS_PATH = path.join(DATA_DIR, 'users.json')
@@ -69,15 +71,20 @@ export function hasAdmin(): boolean { return readUsers().some(u => u.role === '
export function findById(id: string): User | undefined { return readUsers().find(u => u.id === id) }
export function findByUsername(username: string): User | undefined { return readUsers().find(u => u.username.toLowerCase() === username.toLowerCase()) }
export function findByApiToken(token: string): User | undefined { return readUsers().find(u => u.apiToken === token) }
export function findByJellyfinId(id: string): User | undefined {
const jellyfinId = normalizeJellyfinId(id)
return readUsers().find(u => !!u.jellyfinId && u.jellyfinId === jellyfinId)
}
export function safeUser(user: User) {
const { passwordHash: _, ...safe } = user
return safe
const { passwordHash, ...safe } = user
return { ...safe, hasPassword: !!passwordHash }
}
// ── Gestion des comptes ───────────────────────────────────────
export function createUser(username: string, password: string, role: UserRole = 'user'): User {
// Mot de passe vide : compte sans mot de passe FanKarr (import Jellyfin)
export function createUser(username: string, password: string, role: UserRole = 'user', jellyfinId: string | null = null): User {
const users = readUsers()
if (users.some(u => u.username.toLowerCase() === username.toLowerCase())) {
throw new Error(`Nom d'utilisateur « ${username} » déjà utilisé`)
@@ -85,17 +92,43 @@ export function createUser(username: string, password: string, role: UserRole =
const user: User = {
id : crypto.randomUUID(),
username,
passwordHash: bcrypt.hashSync(password, SALT_ROUNDS),
passwordHash: password ? bcrypt.hashSync(password, SALT_ROUNDS) : '',
role,
apiToken : crypto.randomBytes(32).toString('hex'),
createdAt : new Date().toISOString(),
tourSeenAt : null,
jellyfinId : jellyfinId ? normalizeJellyfinId(jellyfinId) : null,
}
writeUsers([...users, user])
logger.info('users', `Utilisateur « ${username} » créé (rôle : ${role})`)
return user
}
export type JellyfinImportAction = 'existing' | 'linked' | 'created'
// null : nom déjà pris par un compte lié à un autre utilisateur Jellyfin
export function importJellyfinUser(jUser: { Id: string; Name: string }): { user: User; action: JellyfinImportAction } | null {
const users = readUsers()
const jellyfinId = normalizeJellyfinId(jUser.Id)
const linked = users.find(u => u.jellyfinId === jellyfinId)
if (linked) return { user: linked, action: 'existing' }
const sameName = users.find(u => u.username.toLowerCase() === jUser.Name.toLowerCase())
if (sameName?.jellyfinId) {
logger.warn('users', `Import Jellyfin de « ${jUser.Name} » impossible : le compte FanKarr de ce nom est lié à un autre utilisateur Jellyfin`)
return null
}
if (sameName) {
sameName.jellyfinId = jellyfinId
writeUsers(users)
logger.info('users', `Compte « ${sameName.username} » lié à l'utilisateur Jellyfin « ${jUser.Name} »`)
return { user: sameName, action: 'linked' }
}
return { user: createUser(jUser.Name, '', 'user', jellyfinId), action: 'created' }
}
export function updateUser(
id : string,
changes: Partial<{ username: string; password: string; role: UserRole }>,
@@ -132,6 +165,7 @@ export function changePassword(id: string, currentPassword: string, newPassword:
const users = readUsers()
const user = users.find(u => u.id === id)
if (!user) throw new Error('Utilisateur introuvable')
if (!user.passwordHash) throw new Error('Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr')
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
throw new Error('Mot de passe actuel incorrect')
}