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>
This commit is contained in:
1 parent
804436ff70
commit
e13d43d186
13 files changed
+349
-18
No files matched your search
@@ -10,6 +10,13 @@
|
||||
# Exemples : "1d", "7d", "30d", "1y" ; "never" ou "0" : pas d'expiration
|
||||
# AUTH_TOKEN_EXPIRY=30d
|
||||
|
||||
# Authentification par proxy (oauth2-proxy, Authelia…) : en-tête contenant le
|
||||
# nom d'utilisateur, lu uniquement pour les adresses de PROXY_AUTH_TRUSTED_PROXIES
|
||||
# PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username
|
||||
# PROXY_AUTH_TRUSTED_PROXIES=127.0.0.1
|
||||
# PROXY_AUTH_AUTO_CREATE=false
|
||||
# PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out
|
||||
|
||||
# ── Serveur ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port d'écoute (défaut : 9898)
|
||||
|
||||
@@ -199,6 +199,10 @@ FanKarr est alors accessible sur `http://localhost:9898`. La configuration, les
|
||||
| `AUTH_TOKEN_EXPIRY` | `30d` | Durée de validité de la session (ex. `7d`, `1y` ; `never` ou `0` : pas d'expiration) |
|
||||
| `GITHUB_BASE` | dépôt scraper | Adresse de base des données du scraper, si vous hébergez votre propre copie |
|
||||
| `BASE_URL` | non défini | Sous-dossier derrière un reverse proxy (ex. `/fankarr`) |
|
||||
| `PROXY_AUTH_HEADER` | non défini | En-tête contenant le nom d'utilisateur authentifié par le proxy (ex. `Remote-User`) |
|
||||
| `PROXY_AUTH_TRUSTED_PROXIES` | non défini | Adresses autorisées à envoyer cet en-tête (IP ou plages IPv4 CIDR, séparées par des virgules) |
|
||||
| `PROXY_AUTH_AUTO_CREATE` | `false` | Crée un compte utilisateur à la première connexion d'un nom inconnu |
|
||||
| `PROXY_AUTH_LOGOUT_URL` | non défini | Page de déconnexion du proxy, ouverte par « Se déconnecter » (ex. `/oauth2/sign_out`) |
|
||||
|
||||
Par défaut, `GITHUB_BASE` vaut `https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main`.
|
||||
|
||||
@@ -233,6 +237,49 @@ Laissez `BASE_URL` vide pour un sous-domaine dédié (`https://fankarr.exemple.t
|
||||
|
||||
---
|
||||
|
||||
### Authentification par proxy (oauth2-proxy, Authelia…)
|
||||
|
||||
Si un proxy d'authentification (oauth2-proxy devant Keycloak, Authelia,
|
||||
Authentik…) contrôle déjà l'accès à FanKarr, FanKarr peut ouvrir la session
|
||||
directement à partir du nom d'utilisateur qu'il transmet, sans second écran de
|
||||
connexion :
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username # en-tête posé par le proxy
|
||||
- PROXY_AUTH_TRUSTED_PROXIES=172.18.0.0/16 # adresse(s) du reverse proxy
|
||||
- PROXY_AUTH_LOGOUT_URL=/oauth2/sign_out # facultatif
|
||||
# - PROXY_AUTH_AUTO_CREATE=true # facultatif
|
||||
```
|
||||
|
||||
- Le nom transmis est rapproché d'un compte FanKarr existant, sans tenir compte
|
||||
de la casse. Un nom inconnu est refusé (avertissement dans les journaux), sauf
|
||||
avec `PROXY_AUTH_AUTO_CREATE=true` : un compte **utilisateur** sans mot de
|
||||
passe est alors créé. Les rôles se gèrent dans Paramètres › Utilisateurs.
|
||||
- L'en-tête n'est lu que pour les connexions venant de
|
||||
`PROXY_AUTH_TRUSTED_PROXIES` : sans cette variable, la fonction reste
|
||||
désactivée. Ne publiez pas le port de FanKarr au-delà du proxy et des
|
||||
applications qui utilisent l'API.
|
||||
- Le premier compte administrateur se crée toujours avec l'assistant.
|
||||
- La connexion par mot de passe et par Jellyfin reste possible, ainsi que les
|
||||
jetons d'API.
|
||||
|
||||
Exemple avec NPMplus et oauth2-proxy : choisissez `oauth2proxy` dans
|
||||
« Auth Request » de la location FanKarr. L'en-tête `X-User` que NPMplus
|
||||
transmet contient l'identifiant interne de l'utilisateur (`sub`, un UUID avec
|
||||
Keycloak) et non son nom de connexion. Transmettez plutôt ce dernier en ajoutant
|
||||
dans la configuration avancée de la location :
|
||||
|
||||
```nginx
|
||||
auth_request_set $preferred_username $upstream_http_x_auth_request_preferred_username;
|
||||
proxy_set_header X-Forwarded-Preferred-Username $preferred_username;
|
||||
```
|
||||
|
||||
puis `PROXY_AUTH_HEADER=X-Forwarded-Preferred-Username`. oauth2-proxy doit
|
||||
tourner avec `--set-xauthrequest`.
|
||||
|
||||
---
|
||||
|
||||
### Premier lancement
|
||||
|
||||
1. Ouvrez `http://localhost:9898` (ou l'adresse de votre serveur).
|
||||
|
||||
+33
-5
@@ -15,6 +15,7 @@ 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'
|
||||
import { proxyAuthUser, PROXY_AUTH_ENABLED, PROXY_AUTH_LOGOUT_URL } from './lib/proxy-auth.js'
|
||||
|
||||
// ── Typage Express étendu ─────────────────────────────────────
|
||||
declare global {
|
||||
@@ -39,13 +40,39 @@ function signToken(user: User): string {
|
||||
: jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY as any })
|
||||
}
|
||||
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
function setSessionCookie(res: Response, user: User): string {
|
||||
user.lastLoginAt = markLogin(user.id) ?? user.lastLoginAt
|
||||
const token = signToken(user)
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax', path: COOKIE_PATH })
|
||||
return token
|
||||
}
|
||||
|
||||
function setCookieAndRespond(res: Response, user: User): void {
|
||||
setSessionCookie(res, user)
|
||||
res.json({ success: true, user: safeUser(user) })
|
||||
}
|
||||
|
||||
// Identifiant du compte de la session, ou undefined si le cookie est absent ou invalide
|
||||
function sessionUserId(req: Request): string | undefined {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) return undefined
|
||||
try { return (jwt.verify(token, JWT_SECRET) as any).id } catch { return undefined }
|
||||
}
|
||||
|
||||
// ── Authentification par proxy ────────────────────────────────
|
||||
// Placé avant les routes : quand le proxy désigne un compte, la session FanKarr
|
||||
// est ouverte (ou remplacée si elle appartient à un autre compte), et la suite
|
||||
// du traitement voit un cookie valide comme après une connexion classique.
|
||||
export function proxyAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const user = proxyAuthUser(req)
|
||||
if (user && sessionUserId(req) !== user.id) {
|
||||
const token = setSessionCookie(res, user)
|
||||
req.cookies = { ...req.cookies, fankarr_token: token }
|
||||
logger.info('auth', `Connexion de « ${user.username} » via le proxy d'authentification (rôle : ${user.role})`)
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────
|
||||
|
||||
function jellyfinLoginEnabled(s: Settings): boolean {
|
||||
@@ -57,21 +84,22 @@ export function authStatus(req: Request, res: Response): void {
|
||||
const settings = readSettings()
|
||||
const setup = hasUsers()
|
||||
const jellyfinLogin = jellyfinLoginEnabled(settings)
|
||||
const proxy = { proxyAuth: PROXY_AUTH_ENABLED, logoutUrl: PROXY_AUTH_LOGOUT_URL || null }
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin }); return }
|
||||
if (!token) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
const user = findById(payload.id)
|
||||
if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin }); return }
|
||||
if (!user) { res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy }); return }
|
||||
res.json({
|
||||
setup, loggedIn: true, jellyfinLogin, role: user.role, username: user.username, userId: user.id,
|
||||
setup, loggedIn: true, jellyfinLogin, ...proxy, role: user.role, username: user.username, userId: user.id,
|
||||
onboardingDone: !!settings.onboardingCompletedAt,
|
||||
tourSeen : !!user.tourSeenAt,
|
||||
})
|
||||
} catch {
|
||||
res.json({ setup, loggedIn: false, jellyfinLogin })
|
||||
res.json({ setup, loggedIn: false, jellyfinLogin, ...proxy })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -3,7 +3,7 @@ import cookieParser from 'cookie-parser'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin } from './auth.js'
|
||||
import { authStatus, authSetup, authLogin, authLogout, authMe, authChangePassword, authRegenerateToken, authTourSeen, requireAuth, requireAdmin, proxyAuth } from './auth.js'
|
||||
import { migrateIfNeeded } from './users.js'
|
||||
import { migrateOnboarding } from './onboarding.js'
|
||||
import { readSettings } from './settings.js'
|
||||
@@ -80,6 +80,8 @@ migrateIfNeeded()
|
||||
migrateOnboarding()
|
||||
|
||||
// ── Authentification ───────────────────────────────────────────
|
||||
// Session ouverte d'après l'en-tête du proxy d'authentification (PROXY_AUTH_HEADER)
|
||||
site.use(proxyAuth)
|
||||
site.get ('/api/auth/status', authStatus)
|
||||
site.post('/api/auth/setup', authSetup)
|
||||
site.post('/api/auth/login', authLogin)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { Request } from 'express'
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
list: [] as { id: string; username: string; role: string }[],
|
||||
}))
|
||||
|
||||
vi.mock('../logger.js', () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../users.js', () => ({
|
||||
hasUsers : () => users.list.length > 0,
|
||||
findByUsername: (name: string) => users.list.find(u => u.username.toLowerCase() === name.toLowerCase()),
|
||||
createUser : (username: string, _password: string, role: string) => {
|
||||
const user = { id: `id-${username}`, username, role }
|
||||
users.list.push(user)
|
||||
return user
|
||||
},
|
||||
}))
|
||||
|
||||
const ENV_KEYS = ['PROXY_AUTH_HEADER', 'PROXY_AUTH_TRUSTED_PROXIES', 'PROXY_AUTH_AUTO_CREATE', 'PROXY_AUTH_LOGOUT_URL']
|
||||
|
||||
async function load(env: Record<string, string>) {
|
||||
for (const key of ENV_KEYS) delete process.env[key]
|
||||
Object.assign(process.env, env)
|
||||
vi.resetModules()
|
||||
return import('./proxy-auth.js')
|
||||
}
|
||||
|
||||
function request(remoteAddress: string, headers: Record<string, string> = {}): Request {
|
||||
return { socket: { remoteAddress }, headers } as unknown as Request
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
users.list = [{ id: 'id-alice', username: 'Alice', role: 'admin' }]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key]
|
||||
})
|
||||
|
||||
describe('adresses de confiance', () => {
|
||||
it('reconnaît une plage IPv4, une adresse exacte et une IPv4 vue par un socket IPv6', async () => {
|
||||
const { parseTrustedProxies, isTrustedAddress } = await load({})
|
||||
const trusted = parseTrustedProxies('172.18.0.0/16, 10.0.0.5, fd00::1')
|
||||
|
||||
expect(isTrustedAddress('172.18.3.4', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('::ffff:172.18.0.2', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('10.0.0.5', trusted)).toBe(true)
|
||||
expect(isTrustedAddress('FD00::1', trusted)).toBe(true)
|
||||
|
||||
expect(isTrustedAddress('172.19.0.1', trusted)).toBe(false)
|
||||
expect(isTrustedAddress('10.0.0.6', trusted)).toBe(false)
|
||||
expect(isTrustedAddress(undefined, trusted)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignore les entrées invalides', async () => {
|
||||
const { parseTrustedProxies } = await load({})
|
||||
expect(parseTrustedProxies('pas-une-ip, 10.0.0.0/40, fd00::/64')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxyAuthUser', () => {
|
||||
const enabled = { PROXY_AUTH_HEADER: 'X-User', PROXY_AUTH_TRUSTED_PROXIES: '172.18.0.0/16' }
|
||||
|
||||
it('est désactivé sans adresse de confiance, même si l\'en-tête est défini', async () => {
|
||||
const mod = await load({ PROXY_AUTH_HEADER: 'X-User' })
|
||||
expect(mod.PROXY_AUTH_ENABLED).toBe(false)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renvoie le compte désigné par un proxy de confiance, sans tenir compte de la casse', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'alice' }))?.id).toBe('id-alice')
|
||||
})
|
||||
|
||||
it('ignore l\'en-tête venant d\'une adresse non déclarée', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('192.168.1.50', { 'x-user': 'alice' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ne fait rien sans en-tête', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ne crée pas de compte inconnu par défaut', async () => {
|
||||
const mod = await load(enabled)
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
|
||||
expect(users.list).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('crée un compte utilisateur avec PROXY_AUTH_AUTO_CREATE', async () => {
|
||||
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
|
||||
const user = mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))
|
||||
expect(user).toMatchObject({ username: 'bob', role: 'user' })
|
||||
})
|
||||
|
||||
it('laisse l\'assistant créer le premier compte', async () => {
|
||||
users.list = []
|
||||
const mod = await load({ ...enabled, PROXY_AUTH_AUTO_CREATE: 'true' })
|
||||
expect(mod.proxyAuthUser(request('172.18.0.2', { 'x-user': 'bob' }))).toBeUndefined()
|
||||
expect(users.list).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('n\'expose l\'adresse de déconnexion que si l\'authentification par proxy est active', async () => {
|
||||
expect((await load({ PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('')
|
||||
expect((await load({ ...enabled, PROXY_AUTH_LOGOUT_URL: '/oauth2/sign_out' })).PROXY_AUTH_LOGOUT_URL).toBe('/oauth2/sign_out')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -165,7 +165,11 @@ 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 (!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')
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function openSetup() {
|
||||
|
||||
async function logout() {
|
||||
open.value = false
|
||||
await auth.logout()
|
||||
if (await auth.logout()) return
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -112,7 +112,7 @@ function openSetup() {
|
||||
const { currentVersion, latestVersion, latestReleaseUrl, updateAvailable } = useUpdateCheck()
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
if (await auth.logout()) return
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
+13
-2
@@ -12,6 +12,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const onboardingDone = ref(true)
|
||||
const tourSeen = ref(true)
|
||||
const jellyfinLogin = ref(false)
|
||||
const proxyAuth = ref(false)
|
||||
const logoutUrl = ref<string | null>(null)
|
||||
|
||||
const isAdmin = computed(() => role.value === 'admin')
|
||||
|
||||
@@ -27,6 +29,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
onboardingDone.value = data.onboardingDone ?? true
|
||||
tourSeen.value = data.tourSeen ?? true
|
||||
jellyfinLogin.value = data.jellyfinLogin ?? false
|
||||
proxyAuth.value = data.proxyAuth ?? false
|
||||
logoutUrl.value = data.logoutUrl ?? null
|
||||
} catch {
|
||||
loggedIn.value = false
|
||||
} finally {
|
||||
@@ -68,14 +72,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return data.error ?? 'Impossible de créer le compte.'
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
// true : la page quitte FanKarr pour la déconnexion du proxy d'authentification,
|
||||
// sans quoi le proxy rouvrirait la session à la requête suivante
|
||||
async function logout(): Promise<boolean> {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
if (logoutUrl.value) {
|
||||
window.location.href = logoutUrl.value
|
||||
return true
|
||||
}
|
||||
loggedIn.value = false
|
||||
role.value = null
|
||||
username.value = null
|
||||
userId.value = null
|
||||
onboardingDone.value = true
|
||||
tourSeen.value = true
|
||||
return false
|
||||
}
|
||||
|
||||
async function markTourSeen() {
|
||||
@@ -96,7 +107,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
loggedIn, setup, loading, role, username, userId, isAdmin,
|
||||
onboardingDone, tourSeen, jellyfinLogin,
|
||||
onboardingDone, tourSeen, jellyfinLogin, proxyAuth, logoutUrl,
|
||||
checkStatus, login, setupAccount, logout, markTourSeen, completeOnboarding,
|
||||
}
|
||||
})
|
||||
@@ -23,7 +23,9 @@
|
||||
<SettingsSection
|
||||
v-if="me?.hasPassword === false"
|
||||
title="Mot de passe"
|
||||
description="Vous vous connectez avec votre compte Jellyfin. Le mot de passe se change dans Jellyfin."
|
||||
:description="me.jellyfin
|
||||
? 'Vous vous connectez avec votre compte Jellyfin. Le mot de passe se change dans Jellyfin.'
|
||||
: 'Vous vous connectez par le proxy d\'authentification de votre serveur : ce compte n\'a pas de mot de passe FanKarr.'"
|
||||
/>
|
||||
|
||||
<SettingsSection v-else title="Mot de passe" description="Au moins 6 caractères.">
|
||||
@@ -97,7 +99,7 @@ import SettingsSection from '@/components/settings/SettingsSection.vue
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean } | null>(null)
|
||||
const me = ref<{ createdAt?: string; lastLoginAt?: string | null; hasPassword?: boolean; jellyfin?: boolean } | null>(null)
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
@@ -159,7 +161,7 @@ async function loadMe() {
|
||||
if (res.ok) {
|
||||
const d = await res.json()
|
||||
myToken.value = d.apiToken ?? ''
|
||||
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword }
|
||||
me.value = { createdAt: d.createdAt, lastLoginAt: d.lastLoginAt, hasPassword: d.hasPassword, jellyfin: !!d.jellyfinId }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
|
||||
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
|
||||
:placeholder="userModal.mode === 'create' ? '••••••••' : userModal.hasPassword ? 'Laissez vide pour le conserver' : 'Aucun mot de passe FanKarr (connexion Jellyfin)'" />
|
||||
:placeholder="userModal.mode === 'create' ? '••••••••' : userModal.hasPassword ? 'Laissez vide pour le conserver' : userModal.jellyfin ? 'Aucun mot de passe FanKarr (connexion Jellyfin)' : 'Aucun mot de passe FanKarr'" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-[7px]">
|
||||
<span class="field-label">Rôle</span>
|
||||
@@ -279,6 +279,7 @@ const userModal = ref<{
|
||||
password: string
|
||||
role : 'admin' | 'user'
|
||||
hasPassword: boolean
|
||||
jellyfin : boolean
|
||||
error : string | null
|
||||
loading : boolean
|
||||
} | null>(null)
|
||||
@@ -362,11 +363,11 @@ onUnmounted(() => document.removeEventListener('click', closeUserMenu))
|
||||
|
||||
// ── Utilisateurs ──
|
||||
function openCreateUser() {
|
||||
userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, error: null, loading: false }
|
||||
userModal.value = { mode: 'create', username: '', password: '', role: 'user', hasPassword: true, jellyfin: false, error: null, loading: false }
|
||||
}
|
||||
|
||||
function openEditUser(u: User) {
|
||||
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, error: null, loading: false }
|
||||
userModal.value = { mode: 'edit', id: u.id, username: u.username, password: '', role: u.role, hasPassword: u.hasPassword, jellyfin: !!u.jellyfinId, error: null, loading: false }
|
||||
}
|
||||
|
||||
async function submitUserModal() {
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"sourceMap" : false
|
||||
},
|
||||
"include": ["server/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "server/**/*.test.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user