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>
216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
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'
|
|
|
|
export interface User {
|
|
id : string
|
|
username : string
|
|
passwordHash: string
|
|
role : UserRole
|
|
apiToken : string
|
|
createdAt : string
|
|
tourSeenAt? : string | null
|
|
lastLoginAt?: string | null
|
|
jellyfinId? : string | null
|
|
}
|
|
|
|
const USERS_PATH = path.join(DATA_DIR, 'users.json')
|
|
const LEGACY_PATH = path.join(DATA_DIR, 'auth.json')
|
|
const SALT_ROUNDS = 10
|
|
|
|
// ── Migration depuis auth.json ────────────────────────────────
|
|
|
|
export function migrateIfNeeded(): void {
|
|
if (fs.existsSync(USERS_PATH)) return
|
|
if (!fs.existsSync(LEGACY_PATH)) return
|
|
|
|
try {
|
|
const legacy = JSON.parse(fs.readFileSync(LEGACY_PATH, 'utf-8')) as {
|
|
username: string
|
|
passwordHash: string
|
|
}
|
|
const admin: User = {
|
|
id : crypto.randomUUID(),
|
|
username : legacy.username,
|
|
passwordHash: legacy.passwordHash,
|
|
role : 'admin',
|
|
apiToken : crypto.randomBytes(32).toString('hex'),
|
|
createdAt : new Date().toISOString(),
|
|
}
|
|
writeUsers([admin])
|
|
logger.info('users', `Migration de auth.json vers users.json réussie pour « ${legacy.username} »`)
|
|
} catch (err) {
|
|
logger.error('users', `Échec de la migration de auth.json : ${err instanceof Error ? err.message : err}`)
|
|
}
|
|
}
|
|
|
|
// ── Fichier des utilisateurs ──────────────────────────────────
|
|
|
|
export function readUsers(): User[] {
|
|
try {
|
|
if (!fs.existsSync(USERS_PATH)) return []
|
|
return JSON.parse(fs.readFileSync(USERS_PATH, 'utf-8')) as User[]
|
|
} catch { return [] }
|
|
}
|
|
|
|
export function writeUsers(users: User[]): void {
|
|
fs.mkdirSync(path.dirname(USERS_PATH), { recursive: true })
|
|
fs.writeFileSync(USERS_PATH, JSON.stringify(users, null, 2))
|
|
}
|
|
|
|
export function hasUsers(): boolean { return readUsers().length > 0 }
|
|
export function hasAdmin(): boolean { return readUsers().some(u => u.role === 'admin') }
|
|
|
|
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, hasPassword: !!passwordHash }
|
|
}
|
|
|
|
// ── Gestion des comptes ───────────────────────────────────────
|
|
|
|
// 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é`)
|
|
}
|
|
const user: User = {
|
|
id : crypto.randomUUID(),
|
|
username,
|
|
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 }>,
|
|
): User {
|
|
const users = readUsers()
|
|
const idx = users.findIndex(u => u.id === id)
|
|
if (idx === -1) throw new Error('Utilisateur introuvable')
|
|
|
|
const user = users[idx]
|
|
|
|
if (changes.username !== undefined && changes.username !== user.username) {
|
|
if (users.some((u, i) => i !== idx && u.username.toLowerCase() === changes.username!.toLowerCase())) {
|
|
throw new Error(`Nom d'utilisateur « ${changes.username} » déjà utilisé`)
|
|
}
|
|
user.username = changes.username
|
|
}
|
|
if (changes.role !== undefined) user.role = changes.role
|
|
if (changes.password !== undefined) user.passwordHash = bcrypt.hashSync(changes.password, SALT_ROUNDS)
|
|
|
|
writeUsers(users)
|
|
logger.info('users', `Utilisateur « ${user.username} » mis à jour`)
|
|
return user
|
|
}
|
|
|
|
export function deleteUser(id: string): void {
|
|
const users = readUsers()
|
|
const user = users.find(u => u.id === id)
|
|
if (!user) throw new Error('Utilisateur introuvable')
|
|
writeUsers(users.filter(u => u.id !== id))
|
|
logger.info('users', `Utilisateur « ${user.username} » supprimé`)
|
|
}
|
|
|
|
export function changePassword(id: string, currentPassword: string, newPassword: string): void {
|
|
const users = readUsers()
|
|
const user = users.find(u => u.id === id)
|
|
if (!user) throw new Error('Utilisateur introuvable')
|
|
if (!user.passwordHash) {
|
|
throw new Error(user.jellyfinId
|
|
? 'Ce compte se connecte avec Jellyfin : il n\'a pas de mot de passe FanKarr'
|
|
: 'Ce compte n\'a pas de mot de passe FanKarr')
|
|
}
|
|
if (!bcrypt.compareSync(currentPassword, user.passwordHash)) {
|
|
throw new Error('Mot de passe actuel incorrect')
|
|
}
|
|
user.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS)
|
|
writeUsers(users)
|
|
logger.info('users', `Mot de passe changé pour « ${user.username} »`)
|
|
}
|
|
|
|
export function markLogin(id: string): string | null {
|
|
const users = readUsers()
|
|
const user = users.find(u => u.id === id)
|
|
if (!user) return null
|
|
user.lastLoginAt = new Date().toISOString()
|
|
writeUsers(users)
|
|
return user.lastLoginAt
|
|
}
|
|
|
|
export function markTourSeen(id: string): string {
|
|
const users = readUsers()
|
|
const user = users.find(u => u.id === id)
|
|
if (!user) throw new Error('Utilisateur introuvable')
|
|
user.tourSeenAt = user.tourSeenAt ?? new Date().toISOString()
|
|
writeUsers(users)
|
|
return user.tourSeenAt
|
|
}
|
|
|
|
export function markAllToursSeen(at: string): number {
|
|
const users = readUsers()
|
|
const pending = users.filter(u => !u.tourSeenAt)
|
|
if (pending.length === 0) return 0
|
|
for (const u of pending) u.tourSeenAt = at
|
|
writeUsers(users)
|
|
return pending.length
|
|
}
|
|
|
|
export function regenerateApiToken(id: string): string {
|
|
const users = readUsers()
|
|
const user = users.find(u => u.id === id)
|
|
if (!user) throw new Error('Utilisateur introuvable')
|
|
user.apiToken = crypto.randomBytes(32).toString('hex')
|
|
writeUsers(users)
|
|
return user.apiToken
|
|
}
|