Files
FanKarr/server/users.ts
T
zacharyandClaude Opus 5.5 e13d43d186 feat(auth): authentification par proxy (oauth2-proxy, Authelia…)
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>
2026-10-03 22:01:57 +02:00

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
}